-
-
Notifications
You must be signed in to change notification settings - Fork 252
refactor(multichain-account-service): Improved performance across package classes and improved error messages #6654
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Conversation
…to wallets and groups
…tead of getAccountByAddress which iterates through the whole of internal accounts in the AccountsController
accountsList, | ||
); | ||
// we cast here because we know that the accounts are BIP-44 compatible | ||
return internalAccounts as Bip44Account<KeyringAccount>[]; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Although the getAccounts
's return type is (InternalAccount | undefined)[]
, we're sure to get back all the accounts we want since the accounts list will never be stale.
…accountAdded and accountRemoved handling, it is dead code
MultichainAccountWallet<Bip44Account<KeyringAccount>> | ||
>; | ||
|
||
readonly #accountIdToContext: Map< |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Decided to get rid of this mapping since it was only being used for handling the accountRemoved
and accountAdded
events, removing this gets rid of a large loop in the init function as well. If there's a particular need for this data at the client level, we can always add this back in.
…handle createNewVaultAndKeychain and createNewVaultAndRestore code paths
…s, remove redundant state assignment, use assert to ensure wallet existence after creation
…ble with new changes
MultichainAccountService
, MultichainAccountWallet
, MultichainAccountGroup
performance and DevX improvements// Add the accounts to the provider's internal list of account IDs | ||
provider.addAccounts(accountIds); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
See my other comment about:
Given the providers are the sources of accounts...
* | ||
* @param accounts - The accounts to add. | ||
*/ | ||
addAccounts(accounts: Bip44Account<KeyringAccount>['id'][]): void { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Given the providers are the sources of accounts, I don't think they should have an addAccounts
method? 🤔
They should be able to hold a list of known accounts once they are initialized? And since we're sharing the same providers instances across the service/wallets/groups, this initialization should happen only once. Further updates to the internal account list happens when a createAccounts
is called on that providers.
WDYT?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is to more so maintain a global list of the ids and reduce the number of calls/work we would have to make to other controllers. I wanted it this way because we were just grabbing the entire list of accounts from the AccountsController
every time we called the getAccount(s)
in the group and provider classes, so maintaining a global list that the provider can fetch from the new AccountsController:getAccounts
method made sense to me.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Well, I think that makes sense that providers keep an internal list of account IDs yes. But the consumers (wallet/group/service here) should have no control over that IMO.
Like, when we call createAccounts
we know we have to update this internal list with new accounts. Same goes for removeAccounts
, if a provider is getting disabled, then it should automatically update its internal list IMO, it's not the consumer role to make sure this list is "consistent".
However, consumers can still react to those actions/events and update their own mapping (basically the #accountToProviders
maps here).
That's why I wanted to avoid to have some state in the providers initially, mainly to not have those mutable methods. But I still think that your internal state makes sense and align with the existing interface, so we should be able to make this work still, no?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hmm that's a fair assessment, I suppose I can move the addAccounts
call to createAccounts
and have the AccountProviderWrapper
clear it's internal list if the provider is disabled when createAccounts
is called in alignment?
packages/multichain-account-service/src/MultichainAccountService.ts
Outdated
Show resolved
Hide resolved
const wallet = new MultichainAccountWallet({ | ||
entropySource, | ||
providers: this.#providers, | ||
messenger: this.#messenger, | ||
}); | ||
wallet.init(serviceState[entropySource]); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I wonder if we could omit the init
part?
I really like that idea of "passing" a in-memory-state to the wallet/groups, because indeed, they are just "wrapper" around methods for their own domain (wallet domain or group domain).
Having the service being the owner of the entire memory layout and passing "views" on each "wallet/group state" would make sense to me.
And I think we could have a similar pattern on the providers too, so the providers can update the state directly, and since every components would share the same "data views", they would get updated automatically too.
Just need to double-check for concurrent accesses if we start sharing the same spaces though 😅
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hmm I'd prefer to keep this pattern, it feels less complicated and more explicit about the intended action to the wallet/group state. I think we would have to end up changing the provider interface as well and would essentially be moving computation here to the provider. We're still reacting to the provider's actions by initing with additional state at the points of creation.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Alright, then WDYT about adding it in the constructor's params? So we are "forced" to pass the initial state of the wallet?
This way, we can also rename init
to setState
maybe, and re-use it in other contexts (cause I think init
makes sense when we init right after constructing an object, but it does sounds "bad" when we just re-use it to "reset" the internal state, so reset
or setState
could be more appropriate for those).
WDYT?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah, I can name it something else.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ok now I remember why I was against the idea of passing it through the constructor, not every wallet will have an initial state i.e. new wallets that haven't had any discovery ran on it. I will however add another method and call it updateState
instead of calling init
(it'll be just the init call under the hood anyway, but for clarity purpose we can call it something else).
const result = accountsController.getAccounts([mockAccount.id]); | ||
|
||
expect(result).toStrictEqual([mockAccount]); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Maybe use multiple accounts instead?
const result = accountsController.getAccounts([mockAccount.id]); | |
expect(result).toStrictEqual([mockAccount]); | |
const result = accountsController.getAccounts([mockAccount.id. mockAccount2.id]); | |
expect(result).toStrictEqual([mockAccount, mockAccount2]); |
readonly #groupIndex: number; | ||
|
||
readonly #providers: NamedAccountProvider<Account>[]; | ||
readonly #providers: BaseBip44AccountProvider[]; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
IMO we should re-introduce the generic parameter for those providers. It was meant to be "extensible" and would align it with the Account
type parameter being used by the top-level class here
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There was a good reason I had to change this, the types were not compatible for some reason which I'm forgetting why honestly (it's been a while this PR has been open 😅). I'll go back and change and see if it's compatible if not, I'll report back here.
return provider.getAccount(id); | ||
|
||
// We cast here because TS cannot infer the type of the account from the provider | ||
return provider.getAccount(id) as Account; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We need the cast because the providers are not using the Account
type parameter. This cast should be gone if we re-add it I guess
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
See above.
return created; | ||
} | ||
return Promise.resolve(); | ||
return Promise.reject(new Error('Already aligned')); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I would not use an error message in that case. Maybe we can re-use what you did in the EvmAccountProvider.#createAccount
last time, like returning a boolean
to indicate if we created accounts or not?
Like const [didCreate, accounts] = ...
. WDYT?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Do you mean changing the provider's createAccounts
return type or returning the tuple in the promise in Promise.allSettled
? I found this method easier because then we're returning a tuple just for this one use case.
packages/multichain-account-service/src/MultichainAccountGroup.ts
Outdated
Show resolved
Hide resolved
packages/multichain-account-service/src/MultichainAccountService.ts
Outdated
Show resolved
Hide resolved
return { | ||
entropySource: account.options.entropy.id, | ||
groupIndex: account.options.entropy.groupIndex, | ||
providerName: provider.getName(), |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I start to think we should introduce a .id
for providers too 🤔 Name is sort of unique today, but I feel like it's more appropriate for logging/debugging.
On the other hand id
fits more in our patterns, and given that we now use it a lot to "map" things, it sounds better to me. WDYT?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't know if it's worth the effort to do that but I'm not completely opposed to it, a provider's name would be unique otherwise it wouldn't be serving it's purpose haha. Are you proposing assigning some uuid in the provider's constructor?
packages/multichain-account-service/src/MultichainAccountService.ts
Outdated
Show resolved
Hide resolved
const wallet = new MultichainAccountWallet({ | ||
entropySource, | ||
providers: this.#providers, | ||
messenger: this.#messenger, | ||
}); | ||
wallet.init(serviceState[entropySource]); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Alright, then WDYT about adding it in the constructor's params? So we are "forced" to pass the initial state of the wallet?
This way, we can also rename init
to setState
maybe, and re-use it in other contexts (cause I think init
makes sense when we init right after constructing an object, but it does sounds "bad" when we just re-use it to "reset" the internal state, so reset
or setState
could be more appropriate for those).
WDYT?
|
||
### Added | ||
|
||
- Add actions for `createNewVaultAndKeychain` and `createNewVaultAndRestore` ([#6654](https://github.com/MetaMask/core/pull/6708)) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
- Add actions for `createNewVaultAndKeychain` and `createNewVaultAndRestore` ([#6654](https://github.com/MetaMask/core/pull/6708)) | |
- Add actions for `createNewVaultAndKeychain` and `createNewVaultAndRestore` ([#6654](https://github.com/MetaMask/core/pull/6654)) |
packages/multichain-account-service/src/providers/EvmAccountProvider.test.ts
Outdated
Show resolved
Hide resolved
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We should probably also split this from this PR. For similar reasons (see my other comment about packages/keyring-controller/src/KeyringController.ts
file)
packages/multichain-account-service/src/providers/BaseBip44AccountProvider.ts
Outdated
Show resolved
Hide resolved
entropySource: this.#entropySource, | ||
groupIndex: targetGroupIndex, | ||
}); | ||
})) as Account[]; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We also need that cast because our provider type does not use the generic type of this class. So IMO, we should re-introduce it.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
See above.
*/ | ||
getAccounts(): Bip44Account<KeyringAccount>[] { | ||
return this.#getAccounts(); | ||
const accountsList = this.#getAccountsList(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Make sure this method is fast enough, it's called very often and previously it was quite slow. You can do something like this to check it:
getAccounts(): Bip44Account<KeyringAccount>[] {
const perf = performance.now()
// ... method code
console.log("getAccounts perf", performance.now() - perf)
return internalAccounts
}
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah, previously this method would iterate through the whole list of accounts but now I've added a method to the accounts controller to just do constant time lookup for each account id. With the refactor we also aren't relying on this method anymore for state construction either so this will no longer be an issue.
|
||
let sync = true; | ||
return serviceState; | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Bug: Service State Initialization Fails for Non-HD Keyrings
The #constructServiceState
method initializes serviceState
only for HD keyrings. If an account's entropy source doesn't correspond to an HD keyring, subsequent attempts to access serviceState[entropySource][groupIndex]
will result in a "Cannot read property 'groupIndex' of undefined" error.
Explanation
MultichainAccountService
createMultichainAccountWallet
) that handles import/restore/new vault.ServiceState
index in one pass and passes state slices to wallets/groups (cuts repeated controller scans/calls).init
path and removed deadaccountIdToContext
mapping.MultichainAccountWallet
init
now consumes a pre-sliced wallet state (entropySource → groups → providerName → ids) instead of querying providers.MultichainAccountGroup
init
registers account IDs per provider and fills reverse maps; callsprovider.addAccounts(ids)
to keep providers in sync.getAccountIds()
for direct access to underlying IDs.BaseBip44AccountProvider
addAccounts(ids: string[])
, enabling providers to track their own account ID lists.getAccounts()
paths rely on known IDs (plural lookups) rather than scanning the full controller list.EvmAccountProvider
getAccount(s)
) for create/discover (removesPerformance Analysis
When fully aligned$g = n / p$ .$g = max(f(p))$ , where $f(p)$ is the number of accounts associated with a provider.
When accounts are not fully aligned then
Consider two scenarios:
General formulas
For Scenario 2, the formulas are as follows:
Before this refactor, the number of loops can be represented$n * p * (1 + w + g)$ , which with $p = 4$ , becomes $n^2 + 4n(1 + w)$ .
Before this refactor, the number of controller calls can be represented as$1 + w + g$ , which with $p = 4$ , becomes $1 + w + n/4$ .
After this refactor, the number of loops can be represented by$n * p$ , which with $p = 4$ , becomes $4n$ .
After this refactor, the number of calls is just$1$ .
For Scenario 1, the formulas are entirely dependent on the breakdown of the number of accounts each provider has amongst the$n$ accounts, let's consider a scenario where Solana has $n/2$ , Ethereum has $n/8$ , Bitcoin has $n/4$ and Tron has $n/8$ , the formulas would be as follows:
Before this refactor, the number of loops in the alignment process can be represented as$(p * g) + (n * e)$ , which with $p=4$ and $g = n/2$ , becomes $2n + 3n^2/8$ . Therefore the number of loops for initialization + alignment in this scenario with $p = 4$ and $g = n/2$ , becomes $(19/8)n^2 + (4w + 6)n$ .
Before this refactor, the number of controller calls in the alignment process can be represented as$e$ , which becomes $3n/8$ . Therefore the number of controller calls for initialization + alignment in this scenario with $p = 4$ , becomes $1 + w + 5n/8$ .
After this refactor, the number of loops in the alignment process can be represented as$p * g$ , which becomes $2n$ . Therefore, the number of loops for initialization + alignment in this scenario with $p = 4$ and $g = n/2$ , becomes $6n$ .
After this refactor, the number of controller calls in the alignment process can be represented as$e$ which becomes $3n/8$ . Therefore, the number of controller calls for initialization + alignment in this scenario with $p = 4$ and $g = n/2$ , becomes $1 + 3n/8$ .
In short, previous
init
performance for loops and controller calls was quadratic and linear, respectively. After, it is linear and constant.Performance Charts
Below are charts that show performance (loops and controller calls)$n = 0$ -> $n = 256$ for Scenario 1 and 2 with $w = 2$ , respectively:
References
N/A
Checklist
Note
Refactors multichain account service to a state-driven model with unified wallet creation (import/create/restore), adds AccountsController getAccounts and KeyringController vault actions, and streamlines providers, alignment, and error handling for better performance.
ServiceState
;MultichainAccountWallet
/MultichainAccountGroup
nowinit
/update
from state slices (remove legacy sync/reverse-mapping paths).createMultichainAccountWallet
supportingimport
,create
, andrestore
flows; emits clearer group/wallet events; improves partial-failure reporting and disabled-provider handling.getAccountIds
); background provider creation updates group state.BaseBip44AccountProvider
: addaddAccounts
, internal ID list, and pluralgetAccounts
via controller lookup;removeAccountsFromList
for disabled cleanup.EvmAccountProvider
: switch to ID-based lookups; deterministic ID from address; retry/timeout helpers retained.getAccounts(accountIds: string[])
method and action; register handler and tests.createNewVaultAndKeychain
andcreateNewVaultAndRestore
.Written by Cursor Bugbot for commit 6f02157. This will update automatically on new commits. Configure here.